<?php
/**
 * WB_S3Request class
 * 
 * NOTE: no need for a fancy client interface since
 *       this class will be primarily used by
 *       derived classes directly.
 * 
 * NOTE: no support for Meta header fields currently
 *       (except for x-amz-acl)
 * 
 * @author Jean-Lou Dupont
 * @version @@package-version@@
 * @id $Id: S3Request.php 154 2008-03-15 02:32:42Z jldupont $
 */
/**
 * setTimeout
 * setResource
 * 
 * 
 */
/*
 * REQUEST:              RESPONSE:
 * GET Bucket            XML [ListBucketResult]
 * GET Bucket Location   XML [LocationConstraint]  [n/s]
 * PUT Bucket            HTTP/1.1 $code  + ACL
 * DELETE Bucket         HTTP/1.1 $code
 * 
 * PUT Object            HTTP/1.1 $code  + ACL 
 * GET Object            HTTP/1.1 $code ... + $data
 * HEAD Object           HTTP/1.1 $code
 * DELETE Object         HTTP/1.1 $code
 * 
 * GET ACL                                        [n/s]
 */
 //{{include
 #require_once 'web-bloks/BaseClass/BaseClass.php';
 #require_once 'web-bloks/ObjFactory/ObjFactory.php'; 
 require_once 'Crypt/HMAC.php';
 require_once 'HTTP/Request.php';
 //include}}
 
 class WB_S3Request
 	extends WB_BaseClass
 {
 	/**
 	 * Used to construct the header of the request
 	 * r: required
 	 * u: urlencodeClean
 	 * m: method that generates value
 	 * d: default
 	 * h: part of HTTP request header
 	 */
 	static $headerFields = array(
		// we trust the derived classes to handle this field correctly
		'Verb' 				=> array( 'r' => true, 'h' => false ),
		
		// required but default provided
		'Host' 				=> array( 'r' => true,  'm' => 'getHost', 'h' => false, 'd' => 's3.amazonaws.com' ),
		
		// filled by an internal function	
		'Date' 				=> array( 'n' => 'Date', 'r' => true,  'm' => 'getDate', 'h' => true ),		
		
		// optional fields
		'UserAgent'			=> array( 'n' => 'User-Agent', 			'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null ),
		//'ContentLength'	=> array( 'n' => 'Content-Length',		'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null ),
		'Expires'			=> array( 'n' => 'Expires',				'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null ),				
		'CacheControl'		=> array( 'n' => 'Cache-Control', 		'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null ),		
		'ContentType'		=> array( 'n' => 'Content-Type', 		'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null ),
		'ContentDisposition'=> array( 'n' => 'Content-Disposition', 'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null ),
		'ContentMD5'		=> array( 'n' => 'Content-MD5',			'r' => false, 							'h'=> true,	'd' => null	),
		'ContentEncoding' 	=> array( 'n' => 'Content-Encoding', 	'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null ),
		//'Xamzacl' 			=> array( 'n' => 'x-amz-acl', 			'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null ),
		
		// Used to control GET Object
		'IfModifiedSince' 	=> array( 'n' => 'If-Modified-Since',	'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null ),
		'IfUnmodifiedSince'	=> array( 'n' => 'If-Unmodified-Since',	'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null  ),		
		'IfMatch' 			=> array( 'n' => 'If-Match',			'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null  ),		
		'IfNoneMatch' 		=> array( 'n' => 'If-None-Match',		'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null  ),						
		
		// filled by the client		
		'Resource' 			=> array( 'r' => true, 	'm' => 'getResource', 'h' => false, 'd' => null ),
		'Document' 			=> array( 'r' => false,	'm' => 'getDocument', 'h' => false, 'd' => null ),		
		'URIParams' 		=> array( 'r' => false,	'm' => 'getURIParams','h' => false, 'd' => null ),				

		// meta fields		
		'xamzacl' 			=> array( 'n' => 'x-amz-acl-',			'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null  ),
		'xamzmeta' 			=> array( 'n' => 'x-amz-meta-',			'r' => false, 'm' => 'urlencodeClean', 'h' => true, 'd' => null  ),
		
		// not directly part of the request header sent BUT
		// useful to use the facility of this table.
		'Timeout' 			=> array( 'r' => false,	'm' => 'getTimeout',  'h' => false, 'd' => 3 ),		

		// filled by an internal function
		// >>> must be the last in this list <<<
		'Authorization' 	=> array( 'n' => 'Authorization',		'r' => true,  'm' => 'getSignature', 'h' => true ),
	);
 	/**
 	 * Class scope secret key
 	 * Default key used if object scope
 	 * one isn't defined.
 	 * @private
 	 */
 	static $classSecretKey = null;
	
 	/**
 	 * Class scope access key
 	 * Default key used if object scope
 	 * one isn't defined.
 	 * @private
 	 */
	static $classAccessKey = null;
	
	/**
	 * Object scope secret key
	 * @private
	 */
 	var $secretKey = null;

	/**
	 * Object scope access key
	 * @private
	 */
	var $accessKey = null;
	
 	/**
 	 * The request header parameters
 	 * All headers end-up here.
 	 * @private
 	 */
 	var $reqHeader = array();
	
 	/**
 	 * Constructor
 	 */
	public function __construct( $params = null )
	{
		parent::__construct( $params );
	}
	/**
	 * Resets the state of the object
	 * in order to proceed with another request.
	 * 
	 * @return $this
	 */
	public function reset()
	{
		$this->reqHeader = array();
		return $this;
	}
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%		
	// WB_BaseClass
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%				
	/**
	 * @see WB_BaseClass::getClassPath
	 */
	public function getClassPath()
	{
		return 'web-bloks/AmazonS3';
	}
	/**
	 * @see WB_BaseClass::getClassVersion
	 * @return 
	 */
	public function getClassVersion()
	{
		return '$Id: S3Request.php 154 2008-03-15 02:32:42Z jldupont $';
	}
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%		
	// Wildcard getter/setter
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
	/**
	 * Wildcard getter/setter dispatch
	 * 
	 * @return mixed
	 * @param $method string
	 * @param $args mixed
	 * @throws 
	 */			
	public function __call( $method, $args )
	{
		$action = strtolower( substr( $method, 0, 3) );
		$field = substr( $method, 3 );
		
		switch($action )
		{
			case 'get':
				return $this->doGet( $field );			
			case 'set':
				return $this->doSet( $field, $args );						
			default:
				$this->throwException( __METHOD__.":unsupported method ($method)" );
		}		
	}			
	/**
	 * Wildcard setter method
	 * 
	 * @return $this
	 * @param $field Object
	 * @param $value Object
	 */
	protected function doSet( $field, $args )
	{
		$value = $args[0];
		
		if ( !isset( self::$headerFields[ $field ] ))
			$this->throwException( __METHOD__.":unsupported header field ($field)" );		
		
		$this->reqHeader [ $field ] = $value;
		
		return $this;
	}

	/**
	 * Wildcard get method
	 * 
	 * @return mixed
	 * @param $field string
	 */			
	protected function doGet( $field )
	{
		if ( !isset( self::$headerFields[ $field ] ))
			$this->throwException( __METHOD__.":unsupported header field ($field)" );		
			
		$d = self::$headerFields[ $field ][ 'd' ];
		
		// if the field isn't set, return the default
		if ( !isset( $this->reqHeader[ $field ]))
			return $d;
			
		return $this->reqHeader[ $field ];
	}
	/**
	 * Sets a specific header field
	 * 
	 * @throws
	 * @return $this
	 */	
	public function setHeaderField( &$field, $value )
	{
		$fieldLC = strtolower( $field );

		if (empty( $fieldLC ))
			return $this;
		
		$result = false; // pessimistic...
		foreach( self::$headerFields as $name => &$entry ) 
		{
			$entryFieldLC = strtolower( @$entry['n'] );
			
			// some entries have no 'n' field...
			if ( empty( $entryFieldLC ))
				continue;
			
			if ( strpos( $fieldLC, $entryFieldLC ) !== false )
			{
				$this->reqHeader[ $name ] = $value;
				$result = true;
			}
		}
					
		// if we get down here...
		if ( !$result )
			$this->throwException( __METHOD__.":unsupported header field ($field)" ); 
			
		return $this;			
	}
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%		
	// Header parameters requiring special care
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%				
	/**
	 * Returns the resource field used
	 * to make the request.
	 * 
	 * @return string
	 */
	public function getResource( )
	{
		$params = $this->formatURIParams();
		
		if (isset( $this->reqHeader['Resource']))
		{
			$x = trim( $this->reqHeader['Resource'] );
			if(substr($x, 0, 1) != "/" )
				$x = "/$x";
			$this->reqHeader['Resource'] = $x;
			
			return $this->reqHeader['Resource'].$params;
		}
		return '/'.$params;
	}
	/**
	 * getDate
	 * Called from the process method
	 * 
	 * @return 
	 */	
	private function getDate( $nil = null, $nil2 = null )
	{
		// use the current date/time		
		return gmdate( DATE_RFC2822 );
	}
	
	public function getDocument()
	{
		if ( !isset( $this->reqHeader['Document'] ))
			return null;
			
		$md5 = $this->hex2b64( md5( $this->reqHeader[ 'Document' ] ) );
		$this->setContentMD5( $md5 );
		
		return $this->reqHeader[ 'Document' ];
	}
	
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%		
	// Interface for derived classes
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%				
	/**
	 * Send the request. 
	 * The request shall be prepared first 
	 * i.e. see prepare()
	 * 
	 * @return 
	 */	
	public function send()
	{
		// process, sanitize, etc.
		$this->process();
		
		return $this->doRequest();
	}
	/**
	 * Sets the class scope keys
	 * 
	 * @return $this
	 * @param $accessKey string
	 * @param $secretKey string
	 */
	public static function setClassKeys( $accessKey, $secretKey )
	{
		self::$classAccessKey = $accessKey;
		self::$classSecretKey = $secretKey;
		return $this;
	}
	/**
	 * Sets the object scope keys
	 * 
	 * @return $this
	 * @param $accessKey string
	 * @param $secretKey string
	 */
	public function setKeys( $accessKey, $secretKey )
	{
		$this->accessKey = $accessKey;
		$this->secretKey = $secretKey;
		return $this;
	}
	/**
	 * Sets the header in full
	 * (not the preferred method)
	 * 
	 * @return $this
	 * @param $params mixed array
	 */
	protected function setHeaders( $params )
	{
		$this->reqHeader = $params;
		return $this;
	}
	/**
	 * Sets a specific header field.
	 * This is especially useful for meta fields.
	 * Only two meta field types are supported:
	 * > x-amz-acl
	 * > x-amz-meta
	 * 
	 * @return $this
	 * @param $key string
	 * @param $value mixed
	 */
	public function setHeaderParam( $key, $value )
	{
		$result = false;
		foreach( self::$metaFieldTypes as $type ) 
			if ( strpos( $key, $type ) !== false )
				$result = true;
				
		if ( !$result )
			$this->throwException( __METHOD__.":invalid meta header ($key)" );

		//@TODO we could be nice and format regardless...
		$this->reqHeader[ $key ] = $value;
		
		return $this;
	}
	static $metaFieldTypes = array(
		'x-amz-acl-',
		'x-amz-meta-'
	);
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%		
	// INTERNAL methods
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
	/**
	 * Performs some sanitization functions etc.
	 * 
	 * @return $this
	 */
	protected function process()
	{
		// verifies that all required fields are present
		// fill-in default values for required parameters if none provided
		foreach( self::$headerFields as $f => &$entry )
		{
			// mandatory parameter?
			$r = $entry[ 'r' ];
			
			// processing method
			$m = isset( $entry[ 'm' ] ) ? $entry['m']:null;
			
			// input parameter to the processing method
			// i.e. the field name e.g. If-None-Match
			$i = isset( $this->reqHeader[ $f ] ) ? $this->reqHeader[ $f ] : null;

			// call the target processing method	
			if ( !is_null( $m ))
				$this->reqHeader[ $f ] = $this->$m( $i, $f );
			
			// is the field set?
			$p = isset( $this->reqHeader[ $f ] ) && ( !is_null( $this->reqHeader[ $f ] ));
			
			// if the field is required BUT isn't set, exception!
			if ( $r && !$p )
				$this->throwException( __METHOD__.":required field missing ( $f )" );
		}
	}
	/**
	 * Figures out which access key to use
	 * 
	 * @return string
	 */
	protected function getAccessKey()
	{
		// precedence is given to object scope
		if ( !is_null( $this->accessKey ))
			return $this->accessKey;
		return self::$classAccessKey;
	}	
	/**
	 * Figures out which secret key to use
	 * @return string
	 */
	protected function getSecretKey()
	{
		// precedence is given to object scope		
		if ( !is_null( $this->secretKey ))
			return $this->secretKey;
		return self::$classSecretKey;
	}	
	/**
	 * Signs a request.
	 * NOTE: if additional AMZ fields are required 
	 *       (in a future version of this class) then
	 *       they must be signed too.
	 * 
	 * @return $request signed original WB_S3Request
	 * @param $request WB_S3Request
	 */
	protected function getSignature( $nil = null )
	{
		$secretKey = $this->getSecretKey();
		$accessKey = $this->getAccessKey();

		// Build and sign the string
		$str  = 			$this->reqHeader['Verb']         . "\n" . 
							$this->reqHeader['ContentMD5']   . "\n" . 
							$this->reqHeader['ContentType']  . "\n" . 
							$this->reqHeader['Date']         . "\n".
							$this->reqHeader['x-amz-acl']    .
							$this->reqHeader['Resource'];
							
	    $hasher = & new Crypt_HMAC( $secretKey, "sha1" );
	    $hashed = $this->hex2b64( $hasher->hash( $str ) );
		
		return "AWS ${accessKey}:${hashed}";
	}
	
	protected function formatURIParams()
	{
		$params = $this->getURIParams();
		$parameters = null;
		if(is_array( $params ))
		{
			foreach($params as $key => $val)
				if ( !is_null( $val ))
					$parameters .= "$key=" . urlencode($val) . "&";

			if ( !empty( $parameters ))					
				$parameters = "?$parameters";
		}
		
		return $parameters;
	}
	/**
	 * Helper for the signature method.
	 */
	function hex2b64($str) 
	{
	    $raw = '';
	    for ($i=0; $i < strlen($str); $i+=2)
	        $raw .= chr(hexdec(substr($str, $i, 2)));
	    return base64_encode($raw);
	}
	/**
	 *  Special urlencode which keeps
	 *  the forward slash / un-encoded
	 *  
	 *  @param string
	 *  @return result string
	 */
	static function urlencodeClean( $input, $field )
	{
		if ( is_null( $input ) )
			return null;
			
		$input = urlencode( $input );
		return str_replace( "%2F", "/", $input );
	}
	/**
	 * doRequest
	 * Actually sends the request
	 * 
	 * @throws
	 * @return WB_S3Response 
	 * @param $req Object
	 * @param $params Object[optional]
	 * @param $document Object[optional]
	 * @param $putBody Object[optional]
	 */
	protected function & doRequest(	)
	{
		// deal with URI parameters
	
		// Prepare Request object
		$request =& new HTTP_Request(	'http://' .
										$this->reqHeader[ 'Host' ] . 
										$this->reqHeader[ 'Resource' ] . 
										$parameters );
										
		$request->_timeout = $this->reqHeader[ 'timeout' ];
		
		// REDIRECTS are a requirement
		$request->_allowRedirects = true;
		
		$request->setMethod( $this->reqHeader['Verb'] );

		// do headers
		foreach( self::$headerFields as $field => &$entry )
			if ( $entry[ 'h' ] )
				if ( !is_null( $this->reqHeader[ $field ] ) )
				{
					$realFieldName = self::$headerFields[ $field ][ 'n' ];
					$request->addHeader(	$realFieldName ,
											$this->reqHeader[ $field ] );
				}
				
		// PUT OBJECT case.
		// fill-in the Content-MD5 field
		$document = $this->getDocument();
		if ( !is_null( $document ) )
			$request->setBody( $document );
		
		try
		{
			$code = $request->sendRequest();			
		}
		catch(Exception $e)
		{
			$this->throwFromException( $e );
		}
		
		if ( PEAR::isError( $code ))
			return false;

		
		$response = WB_ObjFactory::factory( 'WB_S3RawResponse' );
		
		$resource = $this->reqHeader[ 'Resource' ];
		
		$req = $request->_requestHeaders;
	    $head =	$request->getResponseHeader();
		$body = $request->getResponseBody();
		$code = $request->getResponseCode();
	
		$response->init( $resource, $req, $code, $head, $body );
		
		return $response;
	}
	
	
 } // end class declaration